# = Description
#
# flacinfo-rb gives you access to low level information on Flac files.
# * It parses stream information (STREAMINFO).
# * It parses Vorbis comments (VORBIS_COMMENT).
# * It parses the seek table (SEEKTABLE).
# * It parses the 'application metadata block' (APPLICATION).
#   * If (APPLICATION) is ID 0x41544348 (Flac File)
#     then we can parse that too.
# * It recognizes (but does not yet parse) the cue sheet (CUESHEET TRACK).
#
# = Copyright and Disclaimer
# Copyright:: (c) 2006 Darren Kirby
# FlacInfo is free software. 
# No warranty is provided and the author cannot accept responsibility
# for lost or damaged files.
# License:: Ruby
# Author:: Darren Kirby (mailto:bulliver@badcomputer.org)
# Website:: http://badcomputer.org/unix/code/flacinfo/
#
# = More information
#
# * There is an example irb session that shows typical usage at
#   http://badcomputer.org/unix/code/flacinfo/
# * The Flac spec is at:
#   http://flac.sourceforge.net/format.html
# * The Vorbis Comment spec is at:
#   http://www.xiph.org/vorbis/doc/v-comment.html


# FlacInfoError is raised when an error occurs parsing the Flac file.
# It will print an additional error string stating where the error occured.
class FlacInfoError < StandardError
end

# Note: STREAMINFO is the only block guaranteed to be present.
# Other attributes will be present, but empty if the associated block is not present in the Flac file.
class FlacInfo

  # Hash of values extracted from the STREAMINFO block. Keys are 'samplerate', 'bits_per_sample', 'total_samples'
  # 'channels', 'minimum_frame', 'maximum_frame', 'minimum_block', 'maximum_block', 'md5', and 'block_size'
  attr_reader :streaminfo

  # Hash of values extracted from the SEEKTABLE block. Keys are 'seek_points', 'block_size' and 'points'.
  # 'points' is another hash whose keys start at 0 and end at ('seek_points' - 1). Each "seektable['points'][n]" hash 
  # contains an array whose values are [sample number, stream offset, number of frame samples] of each seek point.
  attr_reader :seektable

  # Array of "name=value" strings extracted from the VORBIS_COMMENT block. This is just the contents, metadata is in 'tags'.
  attr_reader :comment

  # Hash of the 'comment' values separated into "key => value" pairs as well as 'vendor_tag' and 'block_size'.
  attr_reader :tags

  # Hash of values extracted from the APPLICATION block. Keys are 'name', 'ID', and 'block_size'.
  attr_reader :application

  # Hash of values extracted from the PADDING block. Just one key: 'block_size'.
  attr_reader :padding

  # Hash of values extracted from the CUESHEET block. Just one key: 'block_size'.
  attr_reader :cuesheet

  # Hash of values extracted from an APPLICATION block if it is type 0x41544348 (Flac File). 
  # Keys are 'description', 'mime_type', and 'raw_data'.
  attr_reader :flac_file

  # FlacInfo is the main class for parsing Flac files
  #
  # :call-seq:
  #   FlacInfo.new(file)   -> FlacInfo instance
  #
  def initialize(filename)
    @filename = filename
    parse_flac_meta_blocks
  end

  # Returns true if @tags[<string>] has a value, false otherwise.
  #
  # :call-seq:
  #   FlacInfo.hastag?(tag)   -> bool
  #
  def hastag?(tag)
    @tags["#{tag}"] ? true : false
  end

  # Prettyprint comment hash.
  #
  # :call-seq:
  #   FlacInfo.print_tags   -> nil
  #
  def print_tags
    @tags.each_pair { |key,val| puts "#{key}: #{val}" }
    nil
  end

  # Prettyprint streaminfo hash
  #
  # :call-seq:
  #   FlacInfo.print_streaminfo   -> nil
  #
  def print_streaminfo
    @streaminfo.each_pair { |key,val| puts "#{key}: #{val}" }
    nil
  end

  # Prettyprint the seektable
  #
  # :call-seq:
  #   FlacInfo.print_seektable   -> nil
  #
  def print_seektable
    puts "  seek points: #{@seektable['seek_points']}"
    n = 0
    @seektable['seek_points'].times do
      print "    point #{n}: sample number: #{@seektable['points'][n][0]}, "
      print "stream offset: #{@seektable['points'][n][1]}, "
      print "frame samples: #{@seektable['points'][n][2]}\n"
      n += 1
    end
    nil
  end

  # This method produces output (mostly) identical to 'metaflac --list'
  #
  # :call-seq:
  #   FlacInfo.meta_flac   -> nil
  #
  def meta_flac
    n = 0
    @metadata_blocks.each do |block|
      puts "METADATA block ##{n}"
      puts "  type: #{block[1]} (#{block[0].upcase})"
      puts "  is last: #{block[2] == 0 ? "false" : "true"}"
      case block[1]
        when 0
          meta_stream
        when 1
          meta_padd
        when 2
          meta_app
        when 3
          meta_seek
        when 4
          meta_vorb
        when 5
          meta_cue
      end
      n += 1
    end
    nil
  end

  # Dumps the contents of flac_file['raw_data']
  #
  # :call-seq:
  #   FlacInfo.raw_data_dump()           -> nil
  #   FlacInfo.raw_data_dump(outfile)   -> nil
  #
  # If passed with 'outfile', the data will be written to a file with that name
  # otherwise it is written to the console (even if binary!).
  #
  def raw_data_dump(outfile = nil)
    if @flac_file == {}
      raise FlacInfoError, "Flac File data not present"
    end
    if outfile == nil
      puts @flac_file['raw_data']
    else
      if @flac_file['mime_type'] =~ /text/
        f = File.new(outfile, "w")
        f.write(@flac_file['raw_data'])
        f.close
      else
        f = File.new(outfile, "wb")
        f.write(@flac_file['raw_data'])
        f.close
      end
    end
  end


  private
  #  The following six methods are just helpers for meta_flac
  def meta_stream
    puts "  length: #{@streaminfo['block_size']}"
    puts "  minumum blocksize: #{@streaminfo['minimum_block']} samples"
    puts "  maximum blocksize: #{@streaminfo['maximum_block']} samples"
    puts "  minimum framesize: #{@streaminfo['minimum_frame']} bytes"
    puts "  maximum framesize: #{@streaminfo['maximum_frame']} bytes"
    puts "  sample rate: #{@streaminfo['samplerate']} Hz"
    puts "  channels: #{@streaminfo['channels']}"
    puts "  bits-per-sample: #{@streaminfo['bits_per_sample']}"
    puts "  total samples: #{@streaminfo['total_samples']}"
    puts "  MD5 signature: #{@streaminfo['md5']}"
  end

  def meta_padd
    puts "  length: #{@padding['block_size']}"
  end

  def meta_app
    puts "  length: #{@application['block_size']}"
    puts "  id: #{@application['ID']}"
    puts "  application name: #{@application['name']}"
    if @application['ID'] == "41544348"
      puts "    description: #{@flac_file['description']}"
      puts "    mime type: #{@flac_file['mime_type']}"
      #  Don't want to dump binary data
      if @flac_file['mime_type'] =~ /text/
        puts "    raw data:"
        puts @flac_file['raw_data']
      else
        puts "'Flac File' data may be binary. Use 'raw_data_dump' to see it"
      end
    else
      puts "    raw data"
      puts @application['raw_data']
    end
  end

  def meta_seek
    puts "  length: #{@seektable['block_size']}"
    print_seektable
  end

  def meta_vorb
    puts "  length: #{@tags['block_size']}"
    puts "  length: #{@tags['vendor_tag']}"
    puts "  comments: #{@comment.size}"
    n = 0
    @comment.each do |c|
      puts "    comment[#{n}]: #{c}"
      n += 1
    end
  end

  def meta_cue
    puts "  length: #{@cuesheet['block_size']}"
  end

  # This is where the 'real' parsing starts.
  def parse_flac_meta_blocks
    @fp = File.new(@filename, "rb")
    @streaminfo = {}
    @comment = {}
    @seektable = {}
    @padding = {}
    @application = {}
    @cuesheet = {}

    typetable = { 0 => "streaminfo", 1 => "padding", 2 => "application",
                  3 => "seektable", 4 => "vorbis_comment", 5 => "cuesheet" }

    header = @fp.read(4)
    #  First 4 bytes must be 0x66, 0x4C, 0x61, and 0x43
    if header != 'fLaC'
      raise FlacInfoError, "#{@filename} does not appear to be a valid Flac file"
    end

    @metadata_blocks = []
    lastheader = 0
    n = 0
    until lastheader == 1
      # first bit = Last-metadata-block flag
      # bits 2-7 = BLOCK_TYPE. See typetable above
      block_header = @fp.read(1).unpack("B*")[0]
      lastheader = block_header[0].to_i & 1
      type = sprintf("%u", "0b#{block_header[1..7]}").to_i
      @metadata_blocks[n] = ["#{typetable[type]}", type, lastheader]
      self.send "parse_#{typetable[type]}"
      n += 1
    end
  end

  def parse_seektable
    begin
      @seektable['block_size'] = @fp.read(3).reverse.unpack("v*")[0]
      @seektable['seek_points'] = @seektable['block_size'] / 18
      n = 0
      @seektable['points'] = {}
      @seektable['seek_points'].times do
        pt_arr = []
        pt_arr << @fp.read(8).reverse.unpack("V*")[0]
        pt_arr << @fp.read(8).reverse.unpack("V*")[0]
        pt_arr << @fp.read(2).reverse.unpack("v*")[0]
        @seektable['points'][n] = pt_arr
        n += 1
      end
    rescue
      raise FlacInfoError, "Could not parse seek table"
    end
  end

  #  Not parsed yet, I have no flacs with a cuesheet!
  def parse_cuesheet
    @cuesheet['block_size'] = @fp.read(3).reverse.unpack("v*")[0]
    @fp.seek(@cuesheet['block_size'], IO::SEEK_CUR)
  end

  def parse_application
    begin
      @application['block_size'] = @fp.read(3).reverse.unpack("v*")[0]
      @application['ID'] = @fp.read(4).unpack("H*")[0]

      #  See http://flac.sourceforge.net/id.html
      app_id = {"41544348" => "Flac File", "43756573" => "GoldWave Cue Points",
                "4D754D4C" => "MusicML", "46696361" => "CUE Splitter",
                "46746F6C" => "flac-tools", "5346464C" => "Sound Font FLAC",
                "7065656D" => "Parseable Embedded Extensible Metadata", "74756E65" => "TagTuner",
                "786D6364" => "xmcd"}

      @application['name'] = "#{app_id[@application['ID']]}"

      #  We only know how to parse data from 'Flac File'...
      if @application['ID'] = "41544348"
        parse_flac_file_contents(@application['block_size'] - 4)
      else
        @application['raw_data'] = @fp.read(@application['block_size'] - 4)
      end
    rescue
      raise FlacInfoError, "Could not parse application block" 
    end
  end

  #  Unlike most values in the Flac header
  #  the Vorbis comments are in LSB order 
  #
  #  @comment is an array of values according to the official spec implementation
  #  @tags is a more user-friendly data structure with the values 
  #  separated into key=value pairs 
  def parse_vorbis_comment
    begin
      @tags = {}
      @tags['block_size'] = @fp.read(3).reverse.unpack("v*")[0]
      vendor_length = @fp.read(4).unpack("V")[0]
      @tags['vendor_tag'] = @fp.read(vendor_length)
      user_comment_list_length = @fp.read(4).unpack("V")[0]
      @comment = []
      n = 0
      user_comment_list_length.times do
        length = @fp.read(4).unpack("V")[0]
        @comment[n] = @fp.read(length)
        n += 1
      end
      @comment.each do |c|
        k,v = c.split("=")
        #  Vorbis spec says we can have more than one identical comment ie:
        #  comment[0]="Artist=Charlie Parker"
        #  comment[1]="Artist=Miles Davis"
        #  so we just append the second and subsequent values to the first
        if @tags.has_key?(k)
          @tags[k] = "#{@tags[k]}, #{v}"
        else
          @tags[k] = v
        end
      end
    rescue
      raise FlacInfoError, "Could not parse Vorbis comments block"
    end
  end

  # padding is just a bunch of '0' bytes
  def parse_padding
    @padding['block_size'] = @fp.read(3).reverse.unpack("v*")[0]
    @fp.seek(@padding['block_size'], IO::SEEK_CUR)
  end

  def parse_streaminfo
    begin
      #  Length (in bytes) of metadata to follow (not including header)
      @streaminfo['block_size']    = @fp.read(3).reverse.unpack("v*")[0]
      @streaminfo['minimum_block'] = @fp.read(2).reverse.unpack("v*")[0]
      @streaminfo['maximum_block'] = @fp.read(2).reverse.unpack("v*")[0]
      @streaminfo['minimum_frame'] = @fp.read(3).reverse.unpack("v*")[0]
      @streaminfo['maximum_frame'] = @fp.read(3).reverse.unpack("v*")[0]

      #  64 bits in MSB order
      bitstring = @fp.read(8).unpack("B*")[0]
      #  20 bits :: Sample rate in Hz.
      @streaminfo['samplerate']      = sprintf("%u", "0b#{bitstring[0..19]}").to_i
      #  3 bits :: (number of channels)-1
      @streaminfo['channels']        = sprintf("%u", "0b#{bitstring[20..22]}").to_i + 1
      #  5 bits :: (bits per sample)-1
      @streaminfo['bits_per_sample'] = sprintf("%u", "0b#{bitstring[23..27]}").to_i + 1
      #  36 bits :: Total samples in stream.
      @streaminfo['total_samples']   = sprintf("%u", "0b#{bitstring[28..63]}").to_i

      #  128 bits :: MD5 signature of the unencoded audio data.
      @streaminfo['md5'] = @fp.read(16).unpack("H32")[0]
    rescue
      raise FlacInfoError, "Could not parse stream info block"
    end
  end

  #  See http://firestuff.org/flacfile/
  def parse_flac_file_contents(size)
    begin
      @flac_file = {}
      desc_length = @fp.read(1).unpack("C")[0]
      @flac_file['description'] = @fp.read(desc_length)
      mime_length = @fp.read(1).reverse.unpack("C")[0]
      @flac_file['mime_type'] = @fp.read(mime_length)
      size = size - 2 - desc_length - mime_length
      @flac_file['raw_data'] = @fp.read(size)
    rescue
      raise FlacInfoError, "Could not parse Flac File data"
    end
  end
end
